/*
* Copyright 2015 Anthem Engineering LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.anthemengineering.mojo.infer;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream;
import org.apache.commons.io.FileUtils;
import org.apache.maven.artifact.DependencyResolutionRequiredException;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.plugins.annotations.ResolutionScope;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.compiler.util.scan.InclusionScanException;
import org.codehaus.plexus.compiler.util.scan.SimpleSourceInclusionScanner;
import org.codehaus.plexus.compiler.util.scan.mapping.SuffixMapping;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Runs Infer over Java source files in the main source (test sources are ignored). Basic results information of the
* Infer check is printed to the console and the output of infer is printed to {@code target/infer-out} in the
* project Maven is run from.
* <p>
* For each source file, an execution of {@code infer -i -o [execution_dir/target/] -- javac [source_file.java]} is run.
* <p>
* If the directory Maven is run from is the parent of a multi module project, Infer results will continue to
* accumulate in {@code target/infer-out/} as each module is built.
* <p>
* Java 8 is not yet supported by Infer.
* <p>
* The {@code PATH} is searched for the Infer script/command; if it is not found, Infer will be downloaded.
*/
@Mojo(name = "infer", defaultPhase = LifecyclePhase.VERIFY,
requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, threadSafe = true)
public class InferMojo extends AbstractMojo {
// constants for downloading
private static final int CONNECTION_TIMEOUT = 60000;
private static final int READ_TIMEOUT = 60000;
private static final String LINUX_INFER_DOWNLOAD_URL =
"https://github.com/anthemengineering/infer-maven-plugin/releases/download/infer-maven-plugin-0.1.0/infer-0.1.1-bin-linux64.tar.xz";
private static final String OSX_INFER_DOWNLOAD_URL =
"https://github.com/anthemengineering/infer-maven-plugin/releases/download/infer-maven-plugin-0.1.0/infer-0.1.1-bin-osx.tar.xz";
// repeated error message
private static final String EARLY_EXECUTION_TERMINATION_EXCEPTION_MSG =
"Problem while waiting for Infer to finish; Infer output may be innacurate.";
// system environment variable property names
private static final String PATH_SEPARATOR = "path.separator";
private static final String LINE_SEPARATOR = "line.separator";
private static final String USER_DIR = "user.dir";
private static final String OS_NAME = "os.name";
// file names
private static final String INFER_BUG_REPORT_FILE_NAME = "bugs.txt";
private static final String INFER_DOWNLOAD_DIRETORY_NAME = "infer-download";
private static final String DEFAULT_MAVEN_BUILD_DIR_NAME = "target";
private static final String INFER_OUTPUT_DIRECTORY_NAME = "infer-out";
private static final String JAVAC_OUTPUT_DIRECTORY_NAME = "javacOut";
private static final String INFER_CMD_NAME = "infer";
// misc strings
private static final String UTF_8 = "UTF-8";
private static final String JAVA_SRC_EXTENSION = ".java";
/**
* Total number of files analyzed during this build.
*/
private static int fileCount;
/**
* Path to Infer script.
*/
private static String inferPath;
/**
* Currently unused, this map keeps track of Infer executions that failed; since there is only one java source file
* specifically targeted per process running Infer, this keeps a map of that source file name to the exit code of
* the Infer process analyzing it.
*/
private static final Map<File, Integer> FAILED_CHECKS = new HashMap<File, Integer>();
@Parameter(defaultValue = "${project}", required = true, readonly = true)
private MavenProject project;
/**
* True if Infer should be downloaded rather than using an installed version. Infer will be installed once per build
* into the build directory of the project Maven was run from.
*/
@Parameter(property = "infer.download", defaultValue = "true")
private boolean download;
/**
* URL from which to download Infer. Overrides defaults; if not specified, Infer is downloaded from the default URL
* for the current operating system (Linux or MacOS X).
*/
@Parameter(property = "infer.downloadUrl")
private String downloadUrl;
/**
* Path to the infer executable/script; or by default {@code infer}, which works when the infer directory has
* been added to the {@code PATH} environment variable.
*/
@Parameter(property = "infer.commandPath", defaultValue = "infer")
private String inferCommand;
/**
* Display the output of each execution of Infer.
*/
@Parameter(property = "infer.consoleOut")
private boolean consoleOut;
/**
* Output directory for Infer.
*/
@Parameter(property = "infer.outputDir")
private String inferDir;
@Override
public void execute() throws MojoExecutionException {
if (inferDir == null) {
inferDir = new File(System.getProperty(USER_DIR), DEFAULT_MAVEN_BUILD_DIR_NAME).getAbsolutePath();
}
if (inferPath != null && !INFER_CMD_NAME.equals(inferPath)) {
getLog().info(String.format("Infer path set to: %s", inferPath));
}
// check if infer is on the PATH and if not, then download it.
if (inferPath == null && download) {
inferPath = downloadInfer(new File(inferDir, INFER_DOWNLOAD_DIRETORY_NAME));
} else if (inferPath == null) {
inferPath = inferCommand;
}
try {
// get source directory, if it doesn't exist then we're done
final File sourceDir = new File(project.getBuild().getSourceDirectory());
if (!sourceDir.exists()) {
return;
}
final File inferOutputDir = getInferOutputDir();
final SimpleSourceInclusionScanner scanner = new SimpleSourceInclusionScanner(
Collections.singleton("**/*.java"), Collections.<String>emptySet());
scanner.addSourceMapping(new SuffixMapping(JAVA_SRC_EXTENSION, Collections.<String>emptySet()));
final Collection<File> sourceFiles = scanner.getIncludedSources(sourceDir, null);
final int numSourceFiles = sourceFiles.size();
fileCount += numSourceFiles;
final String classpath = getRuntimeAndCompileClasspath();
completeInferExecutions(classpath, inferOutputDir, sourceFiles, numSourceFiles);
reportResults(inferOutputDir, numSourceFiles);
} catch (final DependencyResolutionRequiredException e) {
getLog().error(e);
throw new MojoExecutionException("Unable to get required dependencies to perform Infer check!", e);
} catch (final InclusionScanException e) {
getLog().error(e);
throw new MojoExecutionException("Failed to get sources! Cannot complete Infer check", e);
}
}
/**
* Gets/Creates the directory where Infer output will be written.
*
* @return the directory where Infer output will be written
* @throws MojoExecutionException if the Infer output directory cannot be created
*/
private File getInferOutputDir() throws MojoExecutionException {
// infer output to build dir of project maven was run from
final File outputDir = new File(inferDir, INFER_OUTPUT_DIRECTORY_NAME);
try {
FileUtils.forceMkdir(outputDir);
} catch (final IOException e) {
getLog().error(e);
throw new MojoExecutionException("Exception occurred trying to generate output directory for Infer!", e);
}
return outputDir;
}
/**
* Logs results of Infer check to the Maven console.
* @param inferOutputDir directory where Infer wrote its results
* @param numSourceFiles number of source files analyzed in this module
*/
private void reportResults(File inferOutputDir, int numSourceFiles) {
final File bugsFile = new File(inferOutputDir, INFER_BUG_REPORT_FILE_NAME);
getLog().info("Infer output can be located at: " + inferOutputDir);
getLog().info("");
getLog().info("Results of Infer check:");
if (bugsFile.exists()) {
try {
final String bugs;
bugs = FileUtils.readFileToString(bugsFile, UTF_8);
getLog().info(System.getProperty(LINE_SEPARATOR) + System.getProperty(LINE_SEPARATOR) + bugs);
} catch (final IOException e) {
getLog().error(
String.format(
"Exception occurred trying to read bugs report at: %s, no bugs will be reported.",
bugsFile.getAbsolutePath()), e);
}
} else {
getLog().error("No bugs report generated; infer probably did not complete successfully.");
}
getLog().info("");
getLog().info(
String.format(
"Infer review complete; %s files were analyzed for this module, "
+ "%s files have been analyzed so far, in total.", numSourceFiles, fileCount));
// TODO: consider adding this when analyze doesn't fail.
// printFailedChecks();
getLog().info("");
}
/**
* Executes infer once for each source file and writes the output to {@code inferOutputDir}.
*
* @param classpath classpath used as an argument to the javac command given to Infer.
* @param inferOutputDir directory where Infer will write its output
* @param sourceFiles collection of files for Infer to analyze
* @param numSourceFiles number of source files to analyze; used to make sure every Infer execution finishes
* before moving on.
*/
private void completeInferExecutions(
final String classpath, final File inferOutputDir, Collection<File> sourceFiles, int numSourceFiles)
throws MojoExecutionException {
// temporary directory for storing .class files created by {@code javac}; placed in build directory
final File buildTmpDir = new File(project.getBuild().getDirectory(), JAVAC_OUTPUT_DIRECTORY_NAME);
try {
FileUtils.forceMkdir(buildTmpDir);
} catch (final IOException e) {
final String errMsg = String.format("Unable to make temp directory %s!", buildTmpDir.getAbsolutePath());
getLog().error(errMsg, e);
throw new MojoExecutionException(errMsg, e);
}
buildTmpDir.deleteOnExit();
// used to wait for all processes running infer to complete
final CountDownLatch doneSignal = new CountDownLatch(numSourceFiles);
// TODO: optionally allow debugging info? Output directory?
// TODO: a better way to do this may be to determine if there is an entry point that takes a set of source
// files and the classpath and use this. @See mvn, inferj and inferlib in the infer repository.
ExecutorService pool = null;
try {
pool = Executors.newFixedThreadPool(4);
for (final File sourceFile : sourceFiles) {
final Runnable r = new Runnable() {
@Override
public void run() {
Process proc = null;
try {
// infer
final List<String> command = new ArrayList<String>();
command.add(inferPath);
command.add("-i");
command.add("-o");
command.add(inferOutputDir.getAbsolutePath());
command.add("--");
// javac
command.add("javac");
command.add(sourceFile.getAbsolutePath());
command.add("-d");
command.add(buildTmpDir.getAbsolutePath());
command.add("-classpath");
command.add(classpath);
final ProcessBuilder builder = new ProcessBuilder(command);
builder.environment().putAll(System.getenv());
if (consoleOut) {
builder.redirectErrorStream(true);
proc = builder.start();
InputStreamReader isr = null;
BufferedReader br = null;
InputStream pis = null;
try {
pis = proc.getInputStream();
isr = new InputStreamReader(pis);
br = new BufferedReader(isr);
String line = null;
while ((line = br.readLine()) != null) {
getLog().info(line);
}
} catch (final IOException e) {
getLog().error(
String.format(
"Error writing process output for file: %s.",
sourceFile.getAbsolutePath()), e);
} finally {
if (isr != null) {
isr.close();
}
if (br != null) {
br.close();
}
if (pis != null) {
pis.close();
}
}
} else {
// no logging.
proc = builder.start();
}
// NOTE: most/all executions end in failure during analysis, however,
// supported java bugs are still reported
proc.waitFor();
} catch (final IOException e) {
getLog().error(
"Exception occurred while trying to perform Infer execution; output not complete"
+ "", e);
} catch (final InterruptedException e) {
getLog().error(EARLY_EXECUTION_TERMINATION_EXCEPTION_MSG, e);
} finally {
try {
// currently they all fail, although java bugs are still reported
if (proc != null && proc.exitValue() != 0) {
FAILED_CHECKS.put(sourceFile, proc.exitValue());
}
} catch (final Exception e) {
FAILED_CHECKS.put(sourceFile, -1);
}
doneSignal.countDown();
}
}
};
pool.submit(r);
}
} finally {
if (pool != null) {
pool.shutdown();
}
}
try {
doneSignal.await();
} catch (final InterruptedException e) {
getLog().error(EARLY_EXECUTION_TERMINATION_EXCEPTION_MSG, e);
}
}
/**
* Prints the classes which could not be analyzed successfully along with the status code that process failed with.
*/
private void printFailedChecks() {
if (!FAILED_CHECKS.isEmpty()) {
getLog().info("The following checks failed: ");
for (final Entry<File, Integer> entry : FAILED_CHECKS.entrySet()) {
getLog().info(
String.format(
"Class: %s: Exit code: %s.", entry.getKey().getPath(), entry.getValue()));
}
}
}
/**
* Generates a classpath with which source files in the main source directory can be compiled.
*
* @return a String containing the complete classpath, with entries separated by {@code :} so it can be given as the
* classpath argument to javac
* @throws DependencyResolutionRequiredException
*/
private String getRuntimeAndCompileClasspath() throws DependencyResolutionRequiredException {
final List compileClasspathElements = project.getCompileClasspathElements();
final List runtimeClasspathElements = project.getRuntimeClasspathElements();
final Set<String> classPathElements = new HashSet<String>();
classPathElements.addAll(compileClasspathElements);
classPathElements.addAll(runtimeClasspathElements);
final StringBuilder classpath = new StringBuilder();
boolean first = true;
for (final String element : classPathElements) {
if (!first) {
classpath.append(':');
}
classpath.append(element);
first = false;
}
return classpath.toString();
}
/**
* Gets the path to the infer executable; currently unused
*
* @return the absolute path to the {@code infer} executable
*/
private static String getInferPath() {
final String path = System.getenv("PATH");
for (final String dir : path.split(System.getProperty(PATH_SEPARATOR))) {
final File probablyDir = new File(dir);
if (probablyDir.isDirectory()) {
final File maybeInfer = new File(probablyDir, "infer");
if (maybeInfer.exists() && maybeInfer.isFile()) {
return maybeInfer.getAbsolutePath();
}
}
}
return null;
}
/**
* Downloads a distribution of Infer appropriate for the current operating system or fails if the current
* operating system is not supported.
* @param inferDownloadDir directory to which to download Infer
* @return the path to the executable Infer script
* @throws MojoExecutionException if an Exception occurs that should fail execution
*/
private String downloadInfer(File inferDownloadDir) throws MojoExecutionException {
getLog().info("Maven-infer-plugin is configured to download Infer. Downloading now.");
URL url = null;
try {
final OperatingSystem system = currentOs();
if(downloadUrl != null){
url = new URL(downloadUrl);
} else if (system == OperatingSystem.OSX) {
url = new URL(OSX_INFER_DOWNLOAD_URL);
} else if (system == OperatingSystem.LINUX) {
url = new URL(LINUX_INFER_DOWNLOAD_URL);
} else {
final String errMsg = String.format(
"Unsupported operating system: %s. Cannot continue Infer analysis.",
System.getProperty(OS_NAME));
getLog().error(errMsg);
throw new MojoExecutionException(errMsg);
}
getLog().info(String.format("Downloading: %s", downloadUrl.toString()));
final File downloadedFile = new File(inferDownloadDir, url.getFile());
// TODO: could make these configurable
FileUtils.copyURLToFile(url, downloadedFile, CONNECTION_TIMEOUT, READ_TIMEOUT);
getLog().info(String.format("Infer downloaded to %s; now extracting.", inferDownloadDir.getAbsolutePath()));
extract(downloadedFile, inferDownloadDir);
getLog().info("Infer has been extracted, continuing with Infer check.");
final Collection<File> files = FileUtils.listFiles(inferDownloadDir, null, true);
for (final File file : files) {
if (INFER_CMD_NAME.equals(file.getName()) && "bin".equals(file.getParentFile().getName())) {
return file.getAbsolutePath();
}
}
} catch(MalformedURLException e){
final String errMsg = String.format("URL was malformed: " + url);
getLog().error(errMsg, e);
throw new MojoExecutionException(errMsg, e);
} catch (final IOException e) {
final String errMsg = String.format("Unable to get Infer from URL: %s! Cannot continue Infer check.", url);
getLog().error(errMsg, e);
throw new MojoExecutionException(errMsg, e);
}
throw new MojoExecutionException("Unable to download infer! Aborting execution...");
}
/**
* Gets the current operating system, in terms of its relevance to this Mojo.
*
* @return the current operating system or 'UNSUPPORTED'
*/
private static OperatingSystem currentOs() {
final String os = System.getProperty(OS_NAME).toLowerCase();
if (os.contains("mac")) {
return OperatingSystem.OSX;
} else if (os.contains("nix") || os.contains("nux") || os.indexOf("aix") > 0) {
return OperatingSystem.LINUX;
} else {
return OperatingSystem.UNSUPPORTED;
}
}
private enum OperatingSystem {
OSX,
LINUX,
UNSUPPORTED
}
/**
* Extracts a given infer.tar.xz file to the given directory.
*
* @param tarXzToExtract the file to extract
* @param inferDownloadDir the directory to extract the file to
*/
private static void extract(File tarXzToExtract, File inferDownloadDir) throws IOException {
FileInputStream fin = null;
BufferedInputStream in = null;
XZCompressorInputStream xzIn = null;
TarArchiveInputStream tarIn = null;
try {
fin = new FileInputStream(tarXzToExtract);
in = new BufferedInputStream(fin);
xzIn = new XZCompressorInputStream(in);
tarIn = new TarArchiveInputStream(xzIn);
TarArchiveEntry entry;
while ((entry = tarIn.getNextTarEntry()) != null) {
final File fileToWrite = new File(inferDownloadDir, entry.getName());
if (entry.isDirectory()) {
FileUtils.forceMkdir(fileToWrite);
} else {
BufferedOutputStream out = null;
try {
out = new BufferedOutputStream(new FileOutputStream(fileToWrite));
final byte[] buffer = new byte[4096];
int n = 0;
while (-1 != (n = tarIn.read(buffer))) {
out.write(buffer, 0, n);
}
} finally {
if (out != null) {
out.close();
}
}
}
// assign file permissions
final int mode = entry.getMode();
fileToWrite.setReadable((mode & 0004) != 0, false);
fileToWrite.setReadable((mode & 0400) != 0, true);
fileToWrite.setWritable((mode & 0002) != 0, false);
fileToWrite.setWritable((mode & 0200) != 0, true);
fileToWrite.setExecutable((mode & 0001) != 0, false);
fileToWrite.setExecutable((mode & 0100) != 0, true);
}
} finally {
if (tarIn != null) {
tarIn.close();
}
}
}
}